openclacky 1.2.14 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/clacky/agent_config.rb +11 -2
- data/lib/clacky/client.rb +48 -45
- data/lib/clacky/proxy_config.rb +65 -0
- data/lib/clacky/server/http_server.rb +23 -1
- data/lib/clacky/server/scheduler.rb +13 -10
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +56 -6
- data/lib/clacky/web/i18n.js +20 -0
- data/lib/clacky/web/index.html +21 -0
- data/lib/clacky/web/notify.js +154 -0
- data/lib/clacky/web/notify.mp3 +0 -0
- data/lib/clacky/web/settings.js +57 -0
- data/lib/clacky/web/ws-dispatcher.js +8 -0
- data/lib/clacky.rb +3 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 84e7378b08b627bad34d327d1bd82cc7efbfe980a690d64e678242917be8125d
|
|
4
|
+
data.tar.gz: 87e4c1b8e99f2195c98c85124816503fd11436b3bdb38465bb7289fab1204fa3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 36cb343f4a81222b3a2861dcd80529f0d3216a341e19ea7ebfd1ea6dbebded9c0d31a212a645cffb7268e9faf844be9f257d739677a17d0387bf033970dc7675
|
|
7
|
+
data.tar.gz: 16d08fc33223c56024a27072de5a0f435ac969c1ac55fffa39738e0e8d9bfe77f1ebbe4c4cafb8793c2dac1367744daeb417b97db9a854ba7e04ffd4d2b17042
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.15] - 2026-06-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Proxy configuration support
|
|
12
|
+
- Optional sound notification on task completion in Web UI
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Prevent scheduler thread from dying on tick exception
|
|
16
|
+
|
|
17
|
+
### More
|
|
18
|
+
- Tool diff CSS refinement
|
|
19
|
+
|
|
8
20
|
## [1.2.14] - 2026-06-08
|
|
9
21
|
|
|
10
22
|
### Added
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -164,7 +164,8 @@ module Clacky
|
|
|
164
164
|
:models, :current_model_index, :current_model_id,
|
|
165
165
|
:memory_update_enabled, :skill_evolution,
|
|
166
166
|
:max_running_agents, :max_idle_agents,
|
|
167
|
-
:default_working_dir
|
|
167
|
+
:default_working_dir,
|
|
168
|
+
:proxy_url
|
|
168
169
|
|
|
169
170
|
def initialize(options = {})
|
|
170
171
|
@permission_mode = validate_permission_mode(options[:permission_mode])
|
|
@@ -217,6 +218,11 @@ module Clacky
|
|
|
217
218
|
|
|
218
219
|
@default_working_dir = options[:default_working_dir] || ENV["CLACKY_WORKSPACE_DIR"]
|
|
219
220
|
|
|
221
|
+
# HTTP proxy policy. The user's shell ENV (HTTP_PROXY etc.) is always
|
|
222
|
+
# ignored — set proxy_url here to route Clacky's outbound HTTP through
|
|
223
|
+
# a proxy. Leave nil to go direct.
|
|
224
|
+
@proxy_url = options[:proxy_url]
|
|
225
|
+
|
|
220
226
|
# Per-session virtual model overlay.
|
|
221
227
|
# When set, #current_model returns a *merged* hash (the resolved @models
|
|
222
228
|
# entry merged with this overlay) without mutating the shared @models
|
|
@@ -390,6 +396,7 @@ module Clacky
|
|
|
390
396
|
FileUtils.mkdir_p(config_dir)
|
|
391
397
|
File.write(config_file, to_yaml)
|
|
392
398
|
FileUtils.chmod(0o600, config_file)
|
|
399
|
+
Clacky::ProxyConfig.reset_cache! if defined?(Clacky::ProxyConfig)
|
|
393
400
|
end
|
|
394
401
|
|
|
395
402
|
# Convert to YAML format (top-level array)
|
|
@@ -407,6 +414,7 @@ module Clacky
|
|
|
407
414
|
memory_update_enabled
|
|
408
415
|
skill_evolution max_running_agents max_idle_agents
|
|
409
416
|
default_working_dir
|
|
417
|
+
proxy_url
|
|
410
418
|
].freeze
|
|
411
419
|
|
|
412
420
|
# Serialize the current agent configuration to YAML.
|
|
@@ -425,7 +433,8 @@ module Clacky
|
|
|
425
433
|
"skill_evolution" => @skill_evolution,
|
|
426
434
|
"max_running_agents" => @max_running_agents,
|
|
427
435
|
"max_idle_agents" => @max_idle_agents,
|
|
428
|
-
"default_working_dir" => @default_working_dir
|
|
436
|
+
"default_working_dir" => @default_working_dir,
|
|
437
|
+
"proxy_url" => @proxy_url
|
|
429
438
|
}
|
|
430
439
|
YAML.dump("settings" => settings, "models" => persistable_models)
|
|
431
440
|
end
|
data/lib/clacky/client.rb
CHANGED
|
@@ -516,61 +516,64 @@ module Clacky
|
|
|
516
516
|
end
|
|
517
517
|
|
|
518
518
|
def bedrock_connection
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
519
|
+
current_epoch = Clacky::ProxyConfig.epoch
|
|
520
|
+
if @bedrock_connection.nil? ||
|
|
521
|
+
(!@bedrock_connection_epoch.nil? && @bedrock_connection_epoch != current_epoch)
|
|
522
|
+
@bedrock_connection = Faraday.new(url: @base_url) do |conn|
|
|
523
|
+
conn.headers["Content-Type"] = "application/json"
|
|
524
|
+
conn.headers["Authorization"] = "Bearer #{@api_key}"
|
|
525
|
+
conn.options.timeout = @read_timeout || 300
|
|
526
|
+
conn.options.open_timeout = 10
|
|
527
|
+
conn.ssl.verify = false
|
|
528
|
+
conn.adapter Faraday.default_adapter
|
|
529
|
+
end
|
|
530
|
+
@bedrock_connection_epoch = current_epoch
|
|
526
531
|
end
|
|
532
|
+
@bedrock_connection
|
|
527
533
|
end
|
|
528
534
|
|
|
529
535
|
def openai_connection
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
536
|
+
current_epoch = Clacky::ProxyConfig.epoch
|
|
537
|
+
if @openai_connection.nil? ||
|
|
538
|
+
(!@openai_connection_epoch.nil? && @openai_connection_epoch != current_epoch)
|
|
539
|
+
@openai_connection = Faraday.new(url: @base_url) do |conn|
|
|
540
|
+
conn.headers["Content-Type"] = "application/json"
|
|
541
|
+
conn.headers["Authorization"] = "Bearer #{@api_key}"
|
|
542
|
+
conn.options.timeout = @read_timeout || 300
|
|
543
|
+
conn.options.open_timeout = 10
|
|
544
|
+
conn.ssl.verify = false
|
|
545
|
+
conn.adapter Faraday.default_adapter
|
|
546
|
+
end
|
|
547
|
+
@openai_connection_epoch = current_epoch
|
|
537
548
|
end
|
|
549
|
+
@openai_connection
|
|
538
550
|
end
|
|
539
551
|
|
|
540
552
|
def anthropic_connection
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
# wire-level behaviour. Hardcoding rather than exposing as a config
|
|
562
|
-
# field is intentional: the only UAs known to pass the gate are the
|
|
563
|
-
# whitelisted-client formats, and the project's preset registry is
|
|
564
|
-
# the single source of truth for provider-specific quirks (mirroring
|
|
565
|
-
# how the openrouter Bearer-fallback above is hardcoded).
|
|
566
|
-
if @provider_id == "kimi-coding"
|
|
567
|
-
conn.headers["User-Agent"] = "claude-cli/1.0.51 (external, cli)"
|
|
553
|
+
current_epoch = Clacky::ProxyConfig.epoch
|
|
554
|
+
if @anthropic_connection.nil? ||
|
|
555
|
+
(!@anthropic_connection_epoch.nil? && @anthropic_connection_epoch != current_epoch)
|
|
556
|
+
@anthropic_connection = Faraday.new(url: @base_url) do |conn|
|
|
557
|
+
conn.headers["Content-Type"] = "application/json"
|
|
558
|
+
conn.headers["x-api-key"] = @api_key
|
|
559
|
+
conn.headers["anthropic-version"] = "2023-06-01"
|
|
560
|
+
conn.headers["anthropic-dangerous-direct-browser-access"] = "true"
|
|
561
|
+
if @provider_id == "openrouter"
|
|
562
|
+
conn.headers["Authorization"] = "Bearer #{@api_key}"
|
|
563
|
+
end
|
|
564
|
+
# Moonshot's Kimi Code (Coding Plan) endpoint enforces a User-Agent
|
|
565
|
+
# prefix whitelist limited to first-party coding agents.
|
|
566
|
+
if @provider_id == "kimi-coding"
|
|
567
|
+
conn.headers["User-Agent"] = "claude-cli/1.0.51 (external, cli)"
|
|
568
|
+
end
|
|
569
|
+
conn.options.timeout = @read_timeout || 300
|
|
570
|
+
conn.options.open_timeout = 10
|
|
571
|
+
conn.ssl.verify = false
|
|
572
|
+
conn.adapter Faraday.default_adapter
|
|
568
573
|
end
|
|
569
|
-
|
|
570
|
-
conn.options.open_timeout = 10
|
|
571
|
-
conn.ssl.verify = false
|
|
572
|
-
conn.adapter Faraday.default_adapter
|
|
574
|
+
@anthropic_connection_epoch = current_epoch
|
|
573
575
|
end
|
|
576
|
+
@anthropic_connection
|
|
574
577
|
end
|
|
575
578
|
|
|
576
579
|
# Correct relative path for the Anthropic /v1/messages endpoint, accounting
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
# Centralized HTTP proxy policy for the current process.
|
|
5
|
+
#
|
|
6
|
+
# Single source of truth: AgentConfig#proxy_url. We never honour the user's
|
|
7
|
+
# shell ENV (HTTP_PROXY etc.) — it's stripped on every install! so a stale
|
|
8
|
+
# proxy in the launching shell can't poison Clacky.
|
|
9
|
+
#
|
|
10
|
+
# epoch increments on every actual change so that long-lived consumers
|
|
11
|
+
# (e.g. Faraday connections cached on Client instances) can detect when
|
|
12
|
+
# their cached state is stale and rebuild.
|
|
13
|
+
module ProxyConfig
|
|
14
|
+
PROXY_ENV_KEYS = %w[
|
|
15
|
+
http_proxy HTTP_PROXY
|
|
16
|
+
https_proxy HTTPS_PROXY
|
|
17
|
+
all_proxy ALL_PROXY
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
@installed_signature = nil
|
|
21
|
+
@epoch = 0
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
attr_reader :epoch
|
|
25
|
+
|
|
26
|
+
def install!
|
|
27
|
+
url = load_proxy_url
|
|
28
|
+
sig = url
|
|
29
|
+
return if sig == @installed_signature
|
|
30
|
+
|
|
31
|
+
strip_env_proxy
|
|
32
|
+
assign_env_proxy(url) if url && !url.empty?
|
|
33
|
+
ensure_faraday_reads_env
|
|
34
|
+
|
|
35
|
+
@installed_signature = sig
|
|
36
|
+
@epoch += 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset_cache!
|
|
40
|
+
@installed_signature = nil
|
|
41
|
+
install!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private def assign_env_proxy(url)
|
|
45
|
+
%w[http_proxy HTTP_PROXY https_proxy HTTPS_PROXY].each { |k| ENV[k] = url }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private def strip_env_proxy
|
|
49
|
+
PROXY_ENV_KEYS.each { |k| ENV.delete(k) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private def ensure_faraday_reads_env
|
|
53
|
+
return unless defined?(Faraday)
|
|
54
|
+
Faraday.ignore_env_proxy = false if Faraday.respond_to?(:ignore_env_proxy=)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private def load_proxy_url
|
|
58
|
+
cfg = Clacky::AgentConfig.load
|
|
59
|
+
cfg.respond_to?(:proxy_url) ? cfg.proxy_url.to_s.strip : ""
|
|
60
|
+
rescue StandardError
|
|
61
|
+
""
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -3917,7 +3917,8 @@ module Clacky
|
|
|
3917
3917
|
ok: true,
|
|
3918
3918
|
enable_compression: @agent_config.enable_compression,
|
|
3919
3919
|
enable_prompt_caching: @agent_config.enable_prompt_caching,
|
|
3920
|
-
memory_update_enabled: @agent_config.memory_update_enabled
|
|
3920
|
+
memory_update_enabled: @agent_config.memory_update_enabled,
|
|
3921
|
+
proxy_url: @agent_config.proxy_url.to_s
|
|
3921
3922
|
})
|
|
3922
3923
|
end
|
|
3923
3924
|
|
|
@@ -3935,6 +3936,22 @@ module Clacky
|
|
|
3935
3936
|
if body.key?("memory_update_enabled")
|
|
3936
3937
|
@agent_config.memory_update_enabled = !!body["memory_update_enabled"]
|
|
3937
3938
|
end
|
|
3939
|
+
if body.key?("proxy_url")
|
|
3940
|
+
raw = body["proxy_url"].to_s.strip
|
|
3941
|
+
if raw.empty?
|
|
3942
|
+
@agent_config.proxy_url = nil
|
|
3943
|
+
else
|
|
3944
|
+
begin
|
|
3945
|
+
uri = URI.parse(raw)
|
|
3946
|
+
unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
|
|
3947
|
+
return json_response(res, 422, { error: "proxy_url must be a valid http(s) URL" })
|
|
3948
|
+
end
|
|
3949
|
+
rescue URI::InvalidURIError
|
|
3950
|
+
return json_response(res, 422, { error: "proxy_url is not a valid URL" })
|
|
3951
|
+
end
|
|
3952
|
+
@agent_config.proxy_url = raw
|
|
3953
|
+
end
|
|
3954
|
+
end
|
|
3938
3955
|
|
|
3939
3956
|
@agent_config.save
|
|
3940
3957
|
json_response(res, 200, { ok: true })
|
|
@@ -4953,6 +4970,11 @@ module Clacky
|
|
|
4953
4970
|
task.call
|
|
4954
4971
|
@registry.update(session_id, status: :idle, error: nil)
|
|
4955
4972
|
broadcast_session_update(session_id)
|
|
4973
|
+
# Transient global signal for the optional task-complete sound. Sent to
|
|
4974
|
+
# all clients (broadcast_all) so a browser viewing another session — or
|
|
4975
|
+
# with the tab/window in the background — can still chime. Not part of
|
|
4976
|
+
# session history: a chime is a live cue, never replayed on refresh.
|
|
4977
|
+
broadcast_all(type: "task_finished", session_id: session_id)
|
|
4956
4978
|
@session_manager.save(agent.to_session_data(status: :success))
|
|
4957
4979
|
# Start idle compression timer now that the agent is idle
|
|
4958
4980
|
idle_timer&.start
|
|
@@ -192,17 +192,20 @@ module Clacky
|
|
|
192
192
|
|
|
193
193
|
private def run_loop
|
|
194
194
|
loop do
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
195
|
+
begin
|
|
196
|
+
break unless @running
|
|
197
|
+
|
|
198
|
+
tick(Time.now)
|
|
199
|
+
|
|
200
|
+
# Sleep until the start of the next minute
|
|
201
|
+
now = Time.now
|
|
202
|
+
sleep_s = 60 - now.sec
|
|
203
|
+
sleep(sleep_s)
|
|
204
|
+
rescue => e
|
|
205
|
+
Clacky::Logger.error("scheduler_tick_error", error: e)
|
|
206
|
+
sleep(5) # back off before retrying next tick
|
|
207
|
+
end
|
|
203
208
|
end
|
|
204
|
-
rescue => e
|
|
205
|
-
Clacky::Logger.error("scheduler_fatal_error", error: e)
|
|
206
209
|
end
|
|
207
210
|
|
|
208
211
|
# Check all enabled schedules against the given time and fire matching ones.
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -325,6 +325,13 @@ body {
|
|
|
325
325
|
.theme-toggle-btn:active {
|
|
326
326
|
background: var(--color-bg-hover);
|
|
327
327
|
}
|
|
328
|
+
/* Sound-notification toggle shares .theme-toggle-btn; highlight when ON. */
|
|
329
|
+
#notify-toggle-header.notify-on {
|
|
330
|
+
color: var(--color-accent-primary, var(--color-text-primary));
|
|
331
|
+
}
|
|
332
|
+
#notify-toggle-header.notify-on:hover {
|
|
333
|
+
color: var(--color-accent-primary, var(--color-text-primary));
|
|
334
|
+
}
|
|
328
335
|
|
|
329
336
|
/* ── Content Row (Sidebar + Main) ───────────────────────────────────────── */
|
|
330
337
|
#app > aside,
|
|
@@ -2282,19 +2289,31 @@ body {
|
|
|
2282
2289
|
|
|
2283
2290
|
/* ── Diff block (rendered inline within edit/write tool-item) ─────────────── */
|
|
2284
2291
|
.tool-item-diff {
|
|
2285
|
-
margin: 0.
|
|
2286
|
-
padding: 0.
|
|
2292
|
+
margin: 0.375rem 0 0.375rem 1.25rem;
|
|
2293
|
+
padding: 0.5rem 0.625rem;
|
|
2287
2294
|
background: var(--color-bg-secondary);
|
|
2288
2295
|
border: 1px solid var(--color-border-secondary);
|
|
2289
|
-
border-radius:
|
|
2296
|
+
border-radius: 6px;
|
|
2290
2297
|
font-size: 0.6875rem;
|
|
2291
2298
|
font-family: monospace;
|
|
2292
|
-
line-height: 1.
|
|
2299
|
+
line-height: 1.55;
|
|
2293
2300
|
max-height: 20rem;
|
|
2294
|
-
overflow:
|
|
2301
|
+
overflow-x: hidden;
|
|
2302
|
+
overflow-y: auto;
|
|
2303
|
+
scrollbar-width: thin;
|
|
2304
|
+
scrollbar-color: var(--color-border-secondary) transparent;
|
|
2295
2305
|
}
|
|
2306
|
+
.tool-item-diff::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
2307
|
+
.tool-item-diff::-webkit-scrollbar-track { background: transparent; }
|
|
2308
|
+
.tool-item-diff::-webkit-scrollbar-thumb {
|
|
2309
|
+
background: var(--color-border-secondary);
|
|
2310
|
+
border-radius: 3px;
|
|
2311
|
+
}
|
|
2312
|
+
.tool-item-diff::-webkit-scrollbar-thumb:hover { background: var(--color-text-tertiary); }
|
|
2296
2313
|
.diff-line {
|
|
2297
|
-
white-space: pre;
|
|
2314
|
+
white-space: pre-wrap;
|
|
2315
|
+
word-break: break-all;
|
|
2316
|
+
overflow-wrap: anywhere;
|
|
2298
2317
|
padding: 0 0.25rem;
|
|
2299
2318
|
border-radius: 2px;
|
|
2300
2319
|
}
|
|
@@ -3559,6 +3578,37 @@ body {
|
|
|
3559
3578
|
}
|
|
3560
3579
|
.btn-settings-action:hover { background: var(--color-bg-hover); border-color: var(--color-accent-primary); }
|
|
3561
3580
|
.btn-settings-action:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
3581
|
+
|
|
3582
|
+
.settings-network {
|
|
3583
|
+
display: flex;
|
|
3584
|
+
flex-direction: column;
|
|
3585
|
+
gap: 0.875rem;
|
|
3586
|
+
padding: 0.875rem 1rem;
|
|
3587
|
+
background: var(--color-bg-secondary);
|
|
3588
|
+
border: 1px solid var(--color-border-primary);
|
|
3589
|
+
border-radius: 10px;
|
|
3590
|
+
}
|
|
3591
|
+
.settings-network-desc {
|
|
3592
|
+
font-size: 0.8125rem;
|
|
3593
|
+
color: var(--color-text-secondary);
|
|
3594
|
+
line-height: 1.5;
|
|
3595
|
+
margin: 0;
|
|
3596
|
+
}
|
|
3597
|
+
.settings-network-url {
|
|
3598
|
+
display: flex;
|
|
3599
|
+
flex-direction: column;
|
|
3600
|
+
gap: 0.375rem;
|
|
3601
|
+
}
|
|
3602
|
+
.settings-network-url-label {
|
|
3603
|
+
font-size: 0.75rem;
|
|
3604
|
+
color: var(--color-text-secondary);
|
|
3605
|
+
}
|
|
3606
|
+
.settings-network-url-row {
|
|
3607
|
+
display: flex;
|
|
3608
|
+
gap: 0.5rem;
|
|
3609
|
+
align-items: center;
|
|
3610
|
+
}
|
|
3611
|
+
.settings-network-url-row .field-input { flex: 1; }
|
|
3562
3612
|
.settings-loading, .settings-empty, .settings-error {
|
|
3563
3613
|
color: var(--color-text-secondary);
|
|
3564
3614
|
font-size: 0.8125rem;
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -598,6 +598,14 @@ const I18n = (() => {
|
|
|
598
598
|
"settings.browser.btn": "🌐 Configure Browser",
|
|
599
599
|
"settings.browser.btn.reconfigure": "🌐 Reconfigure Browser",
|
|
600
600
|
"settings.browser.btn.starting": "Starting…",
|
|
601
|
+
"settings.network.title": "Network",
|
|
602
|
+
"settings.network.desc": "Clacky always ignores HTTP_PROXY / HTTPS_PROXY from your shell. Set an explicit proxy URL below to route Clacky's outbound traffic through a proxy.",
|
|
603
|
+
"settings.network.proxyUrl": "Proxy URL",
|
|
604
|
+
"settings.network.save": "Save",
|
|
605
|
+
"settings.network.saved": "Saved",
|
|
606
|
+
"settings.network.clear": "Clear",
|
|
607
|
+
"settings.network.cleared": "Cleared — direct connection",
|
|
608
|
+
"settings.network.invalidUrl": "Invalid URL — use http://host:port or http://user:pass@host:port",
|
|
601
609
|
"settings.brand.title": "Brand & License",
|
|
602
610
|
"settings.brand.label.brand": "Brand",
|
|
603
611
|
"settings.brand.label.status": "Status",
|
|
@@ -698,6 +706,8 @@ const I18n = (() => {
|
|
|
698
706
|
"brand.banner.freePromptBoth": "Welcome to {{name}} — {{free}} free skill{{freePlural}} ready to use, plus {{paid}} premium skill{{paidPlural}} unlockable with a serial number.",
|
|
699
707
|
|
|
700
708
|
"header.owner.tooltip": "Creator — click to open Creator Hub",
|
|
709
|
+
"notify.tooltip.on": "Sound on task complete: ON (click to mute)",
|
|
710
|
+
"notify.tooltip.off": "Sound on task complete: OFF (click to enable)",
|
|
701
711
|
|
|
702
712
|
// ── Session info bar / Model switcher benchmark ──
|
|
703
713
|
"sib.bench.btn": "Benchmark",
|
|
@@ -1339,6 +1349,14 @@ const I18n = (() => {
|
|
|
1339
1349
|
"settings.browser.btn": "🌐 配置浏览器",
|
|
1340
1350
|
"settings.browser.btn.reconfigure": "🌐 重新配置浏览器",
|
|
1341
1351
|
"settings.browser.btn.starting": "启动中…",
|
|
1352
|
+
"settings.network.title": "网络",
|
|
1353
|
+
"settings.network.desc": "Clacky 始终忽略系统的 HTTP_PROXY / HTTPS_PROXY。如需让 Clacky 走代理,请在下方填入显式代理地址。",
|
|
1354
|
+
"settings.network.proxyUrl": "代理地址",
|
|
1355
|
+
"settings.network.save": "保存",
|
|
1356
|
+
"settings.network.saved": "已保存",
|
|
1357
|
+
"settings.network.clear": "清除",
|
|
1358
|
+
"settings.network.cleared": "已清除 — 直连",
|
|
1359
|
+
"settings.network.invalidUrl": "地址格式不正确,请使用 http://host:port 或 http://user:pass@host:port",
|
|
1342
1360
|
"settings.brand.title": "品牌 & 授权",
|
|
1343
1361
|
"settings.brand.label.brand": "品牌",
|
|
1344
1362
|
"settings.brand.label.status": "状态",
|
|
@@ -1439,6 +1457,8 @@ const I18n = (() => {
|
|
|
1439
1457
|
"brand.banner.freePromptBoth": "欢迎使用 {{name}} — 已自动安装 {{free}} 个免费技能,还有 {{paid}} 个增值技能可输入序列号解锁。",
|
|
1440
1458
|
|
|
1441
1459
|
"header.owner.tooltip": "创作者 — 点击进入创作者中心",
|
|
1460
|
+
"notify.tooltip.on": "任务完成提示音:已开启(点击关闭)",
|
|
1461
|
+
"notify.tooltip.off": "任务完成提示音:已关闭(点击开启)",
|
|
1442
1462
|
|
|
1443
1463
|
// ── 会话信息栏 / 模型切换器 测速 ──
|
|
1444
1464
|
"sib.bench.btn": "测速",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -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
|
data/lib/clacky/web/settings.js
CHANGED
|
@@ -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).
|
|
@@ -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"
|
|
@@ -165,3 +166,5 @@ module Clacky
|
|
|
165
166
|
class BrowserNotReachableError < AgentError; end # Chrome/Edge not running or remote debugging disabled
|
|
166
167
|
# BrowserManager singleton: Clacky::BrowserManager.instance
|
|
167
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.
|
|
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-
|
|
11
|
+
date: 2026-06-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -429,6 +429,7 @@ files:
|
|
|
429
429
|
- lib/clacky/plain_ui_controller.rb
|
|
430
430
|
- lib/clacky/platform_http_client.rb
|
|
431
431
|
- lib/clacky/providers.rb
|
|
432
|
+
- lib/clacky/proxy_config.rb
|
|
432
433
|
- lib/clacky/rich_ui_controller.rb
|
|
433
434
|
- lib/clacky/server/browser_manager.rb
|
|
434
435
|
- lib/clacky/server/channel.rb
|
|
@@ -552,6 +553,8 @@ files:
|
|
|
552
553
|
- lib/clacky/web/marked.min.js
|
|
553
554
|
- lib/clacky/web/mcp.js
|
|
554
555
|
- lib/clacky/web/model-tester.js
|
|
556
|
+
- lib/clacky/web/notify.js
|
|
557
|
+
- lib/clacky/web/notify.mp3
|
|
555
558
|
- lib/clacky/web/onboard.js
|
|
556
559
|
- lib/clacky/web/profile.js
|
|
557
560
|
- lib/clacky/web/sessions.js
|