openclacky 1.2.14 → 1.2.16
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 +21 -0
- data/lib/clacky/agent_config.rb +11 -2
- data/lib/clacky/client.rb +48 -45
- data/lib/clacky/providers.rb +3 -0
- data/lib/clacky/proxy_config.rb +65 -0
- data/lib/clacky/server/http_server.rb +33 -3
- data/lib/clacky/server/scheduler.rb +13 -10
- data/lib/clacky/utils/model_pricing.rb +17 -0
- 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/model-tester.js +2 -1
- data/lib/clacky/web/notify.js +154 -0
- data/lib/clacky/web/notify.mp3 +0 -0
- data/lib/clacky/web/settings.js +66 -6
- data/lib/clacky/web/ws-dispatcher.js +8 -0
- data/lib/clacky.rb +3 -0
- data/scripts/install.ps1 +45 -19
- 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: b0bb463f7cc0c691496a5dcb6e0d0d7e2e5f20eab12dddee17049f35588755e8
|
|
4
|
+
data.tar.gz: c046bc3d50ebb6624e15b19bbf727a763930d9182ac2b650ea8bc4f4a9b8ea6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 10f9b0c800eb9756f138fc3ed583aceadd89ee7b0bee2d47a3b909b5136e171abb1d8e69c7a8569e2c0642c6839d13d082f956300b56070d7617674d05a73ef5
|
|
7
|
+
data.tar.gz: f730ed4911745afebf9fe8b30d3084b5b8922f26322dc4b55049e2700774fd2a5a5a6200c74b6fc03f3bfc20db689fe9cfa9c78476c0f8809b59aa23b1f9340f
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ 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.16] - 2026-06-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Claude Fable 5 model support
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Model test failed when using saved API key
|
|
15
|
+
- Windows WSL install success rate
|
|
16
|
+
|
|
17
|
+
## [1.2.15] - 2026-06-10
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- Proxy configuration support
|
|
21
|
+
- Optional sound notification on task completion in Web UI
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Prevent scheduler thread from dying on tick exception
|
|
25
|
+
|
|
26
|
+
### More
|
|
27
|
+
- Tool diff CSS refinement
|
|
28
|
+
|
|
8
29
|
## [1.2.14] - 2026-06-08
|
|
9
30
|
|
|
10
31
|
### 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
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Clacky
|
|
|
31
31
|
"api" => "bedrock",
|
|
32
32
|
"default_model" => "abs-claude-sonnet-4-6",
|
|
33
33
|
"models" => [
|
|
34
|
+
"abs-claude-fable-5",
|
|
34
35
|
"abs-claude-opus-4-8",
|
|
35
36
|
"abs-claude-opus-4-7",
|
|
36
37
|
"abs-claude-opus-4-6",
|
|
@@ -80,6 +81,7 @@ module Clacky
|
|
|
80
81
|
# themselves, so they're intentionally not listed here as keys —
|
|
81
82
|
# no injection happens when the default model is already lite-class.
|
|
82
83
|
"lite_models" => {
|
|
84
|
+
"abs-claude-fable-5" => "abs-claude-haiku-4-5",
|
|
83
85
|
"abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
|
|
84
86
|
"abs-claude-opus-4-7" => "abs-claude-haiku-4-5",
|
|
85
87
|
"abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
|
|
@@ -91,6 +93,7 @@ module Clacky
|
|
|
91
93
|
# Fallback chain: if a model is unavailable, try the next one in order.
|
|
92
94
|
# Keys are primary model names; values are the fallback model to use instead.
|
|
93
95
|
"fallback_models" => {
|
|
96
|
+
"abs-claude-fable-5" => "abs-claude-opus-4-8",
|
|
94
97
|
"abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
|
|
95
98
|
},
|
|
96
99
|
"website_url" => "https://www.openclacky.com/ai-keys"
|
|
@@ -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 })
|
|
@@ -4124,8 +4141,16 @@ module Clacky
|
|
|
4124
4141
|
|
|
4125
4142
|
api_key = body["api_key"].to_s
|
|
4126
4143
|
if api_key.include?("****")
|
|
4127
|
-
|
|
4128
|
-
|
|
4144
|
+
model_id = body["id"].to_s
|
|
4145
|
+
entry = nil
|
|
4146
|
+
if !model_id.empty?
|
|
4147
|
+
entry = @agent_config.models.find { |m| m["id"] == model_id }
|
|
4148
|
+
end
|
|
4149
|
+
if entry.nil? && body.key?("index")
|
|
4150
|
+
entry = @agent_config.models[body["index"].to_i]
|
|
4151
|
+
end
|
|
4152
|
+
entry ||= @agent_config.models[@agent_config.current_model_index]
|
|
4153
|
+
api_key = entry ? entry["api_key"].to_s : ""
|
|
4129
4154
|
end
|
|
4130
4155
|
|
|
4131
4156
|
model = body["model"].to_s
|
|
@@ -4953,6 +4978,11 @@ module Clacky
|
|
|
4953
4978
|
task.call
|
|
4954
4979
|
@registry.update(session_id, status: :idle, error: nil)
|
|
4955
4980
|
broadcast_session_update(session_id)
|
|
4981
|
+
# Transient global signal for the optional task-complete sound. Sent to
|
|
4982
|
+
# all clients (broadcast_all) so a browser viewing another session — or
|
|
4983
|
+
# with the tab/window in the background — can still chime. Not part of
|
|
4984
|
+
# session history: a chime is a live cue, never replayed on refresh.
|
|
4985
|
+
broadcast_all(type: "task_finished", session_id: session_id)
|
|
4956
4986
|
@session_manager.save(agent.to_session_data(status: :success))
|
|
4957
4987
|
# Start idle compression timer now that the agent is idle
|
|
4958
4988
|
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.
|
|
@@ -8,6 +8,21 @@ module Clacky
|
|
|
8
8
|
# All pricing is based on official API documentation
|
|
9
9
|
PRICING_TABLE = {
|
|
10
10
|
# Claude 4.5 models - tiered pricing based on prompt length
|
|
11
|
+
"claude-fable-5" => {
|
|
12
|
+
input: {
|
|
13
|
+
default: 10.00, # $10/MTok for prompts ≤ 200K tokens
|
|
14
|
+
over_200k: 10.00 # same for all tiers
|
|
15
|
+
},
|
|
16
|
+
output: {
|
|
17
|
+
default: 50.00, # $50/MTok for prompts ≤ 200K tokens
|
|
18
|
+
over_200k: 50.00 # same for all tiers
|
|
19
|
+
},
|
|
20
|
+
cache: {
|
|
21
|
+
write: 12.50, # $12.50/MTok cache write (5-min tier)
|
|
22
|
+
read: 1.00 # $1.00/MTok cache read
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
|
|
11
26
|
"claude-opus-4.5" => {
|
|
12
27
|
input: {
|
|
13
28
|
default: 5.00, # $5/MTok for prompts ≤ 200K tokens
|
|
@@ -633,6 +648,8 @@ module Clacky
|
|
|
633
648
|
# Support both dot and dash separators (e.g., "4.5", "4-5", "4-6")
|
|
634
649
|
# Also handles Bedrock cross-region prefixes (e.g. "jp.anthropic.claude-sonnet-4-6")
|
|
635
650
|
case model
|
|
651
|
+
when /claude.*fable.*5/i
|
|
652
|
+
"claude-fable-5"
|
|
636
653
|
when /claude.*opus.*4[.-]?[5-9]/i
|
|
637
654
|
"claude-opus-4.5"
|
|
638
655
|
when /claude.*sonnet.*4[.-]?[5-9]/i
|
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": "Proxy is disabled by default. To enable, enter an HTTP/HTTPS proxy URL below (SOCKS5 is not supported).",
|
|
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": "默认不启用代理,如需启用,请填写 HTTP/HTTPS 代理地址(不支持 SOCKS5)。",
|
|
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>
|
|
@@ -6,8 +6,9 @@ window.ModelTester = (function () {
|
|
|
6
6
|
// { ok: true, base_url, message } — connected, no rewrite
|
|
7
7
|
// { ok: true, base_url, message, rewrote: true } — connected, base_url auto-corrected (/v1 appended)
|
|
8
8
|
// { ok: false, message } — failed (server-reported or network)
|
|
9
|
-
async function testConnection({ model, base_url, api_key, anthropic_format, index } = {}) {
|
|
9
|
+
async function testConnection({ model, base_url, api_key, anthropic_format, index, id } = {}) {
|
|
10
10
|
const body = { model, base_url, api_key };
|
|
11
|
+
if (typeof id === "string" && id) body.id = id;
|
|
11
12
|
if (typeof index === "number") body.index = index;
|
|
12
13
|
if (anthropic_format) body.anthropic_format = true;
|
|
13
14
|
|
|
@@ -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
|
|
|
@@ -291,7 +292,8 @@ const Settings = (() => {
|
|
|
291
292
|
model: model.model,
|
|
292
293
|
base_url: model.base_url,
|
|
293
294
|
api_key: model.api_key_masked,
|
|
294
|
-
|
|
295
|
+
id: model.id,
|
|
296
|
+
index: typeof model.index === "number" ? model.index : index,
|
|
295
297
|
anthropic_format: model.anthropic_format
|
|
296
298
|
});
|
|
297
299
|
_showTestResult(index, result.ok, result.message);
|
|
@@ -317,12 +319,16 @@ const Settings = (() => {
|
|
|
317
319
|
// routing for Claude sub-models.
|
|
318
320
|
const anthropic_format = _modalSelectedProviderId === "anthropic";
|
|
319
321
|
|
|
322
|
+
const isNew = index < 0;
|
|
323
|
+
const existing = isNew ? {} : (_models[index] || {});
|
|
324
|
+
const existingId = existing.id || null;
|
|
325
|
+
|
|
320
326
|
// Step 1: Test first
|
|
321
327
|
saveBtn.textContent = I18n.t("settings.models.btn.testing");
|
|
322
328
|
_showModalTestResult(null, "");
|
|
323
329
|
|
|
324
330
|
const result = await ModelTester.testConnection({
|
|
325
|
-
model, base_url, api_key, index, anthropic_format
|
|
331
|
+
model, base_url, api_key, index, id: existingId, anthropic_format
|
|
326
332
|
});
|
|
327
333
|
|
|
328
334
|
if (result.rewrote) {
|
|
@@ -342,9 +348,7 @@ const Settings = (() => {
|
|
|
342
348
|
// Step 2: Save
|
|
343
349
|
saveBtn.textContent = I18n.t("settings.models.btn.saving");
|
|
344
350
|
|
|
345
|
-
const
|
|
346
|
-
const existing = isNew ? {} : (_models[index] || {});
|
|
347
|
-
const hasId = !!existing.id;
|
|
351
|
+
const hasId = !!existingId;
|
|
348
352
|
|
|
349
353
|
const payload = { model, base_url, anthropic_format };
|
|
350
354
|
const setDefault = document.getElementById("model-modal-set-default").checked;
|
|
@@ -366,7 +370,7 @@ const Settings = (() => {
|
|
|
366
370
|
return;
|
|
367
371
|
}
|
|
368
372
|
|
|
369
|
-
const saveResult = await ModelTester.saveModel(payload, { existingId: hasId ?
|
|
373
|
+
const saveResult = await ModelTester.saveModel(payload, { existingId: hasId ? existingId : null });
|
|
370
374
|
|
|
371
375
|
if (saveResult.ok) {
|
|
372
376
|
saveBtn.textContent = I18n.t("settings.models.btn.saved");
|
|
@@ -1200,6 +1204,62 @@ const Settings = (() => {
|
|
|
1200
1204
|
}
|
|
1201
1205
|
}
|
|
1202
1206
|
|
|
1207
|
+
// ── Network / Proxy ───────────────────────────────────────────────────────────
|
|
1208
|
+
|
|
1209
|
+
async function _initNetworkSettings() {
|
|
1210
|
+
const urlInput = document.getElementById("settings-proxy-url");
|
|
1211
|
+
const saveBtn = document.getElementById("btn-save-proxy-url");
|
|
1212
|
+
const clearBtn = document.getElementById("btn-clear-proxy-url");
|
|
1213
|
+
const status = document.getElementById("settings-proxy-url-status");
|
|
1214
|
+
if (!urlInput || !saveBtn) return;
|
|
1215
|
+
|
|
1216
|
+
try {
|
|
1217
|
+
const res = await fetch("/api/config/settings");
|
|
1218
|
+
const data = await res.json();
|
|
1219
|
+
if (data.ok) {
|
|
1220
|
+
urlInput.value = data.proxy_url || "";
|
|
1221
|
+
}
|
|
1222
|
+
} catch (_) { /* non-critical */ }
|
|
1223
|
+
|
|
1224
|
+
async function _patchProxyUrl(value, successKey) {
|
|
1225
|
+
status.textContent = "";
|
|
1226
|
+
status.className = "model-test-result";
|
|
1227
|
+
try {
|
|
1228
|
+
const res = await fetch("/api/config/settings", {
|
|
1229
|
+
method: "PATCH",
|
|
1230
|
+
headers: { "Content-Type": "application/json" },
|
|
1231
|
+
body: JSON.stringify({ proxy_url: value })
|
|
1232
|
+
});
|
|
1233
|
+
const data = await res.json();
|
|
1234
|
+
if (data.ok) {
|
|
1235
|
+
status.textContent = I18n.t(successKey);
|
|
1236
|
+
status.className = "model-test-result success";
|
|
1237
|
+
} else {
|
|
1238
|
+
status.textContent = data.error || I18n.t("settings.network.invalidUrl");
|
|
1239
|
+
status.className = "model-test-result error";
|
|
1240
|
+
}
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
status.textContent = e.message || I18n.t("settings.network.invalidUrl");
|
|
1243
|
+
status.className = "model-test-result error";
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (!saveBtn.dataset.bound) {
|
|
1248
|
+
saveBtn.dataset.bound = "1";
|
|
1249
|
+
saveBtn.addEventListener("click", () => {
|
|
1250
|
+
_patchProxyUrl(urlInput.value.trim(), "settings.network.saved");
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (clearBtn && !clearBtn.dataset.bound) {
|
|
1255
|
+
clearBtn.dataset.bound = "1";
|
|
1256
|
+
clearBtn.addEventListener("click", () => {
|
|
1257
|
+
urlInput.value = "";
|
|
1258
|
+
_patchProxyUrl("", "settings.network.cleared");
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1203
1263
|
// ── Brand & License ───────────────────────────────────────────────────────────
|
|
1204
1264
|
|
|
1205
1265
|
// 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!
|
data/scripts/install.ps1
CHANGED
|
@@ -66,6 +66,15 @@ function Write-Warn { param($msg) Write-Host " [!] $msg" -ForegroundColor Ye
|
|
|
66
66
|
function Write-Fail { param($msg) Write-Host " [x] $msg" -ForegroundColor Red }
|
|
67
67
|
function Write-Step { param($msg) Write-Host "`n==> $msg" -ForegroundColor Blue }
|
|
68
68
|
|
|
69
|
+
# Exit codes (consumed by GUI installer to map to localized error messages).
|
|
70
|
+
$EXIT_OK = 0
|
|
71
|
+
$EXIT_GENERIC_ERROR = 1
|
|
72
|
+
$EXIT_NETWORK_ERROR = 2
|
|
73
|
+
$EXIT_DISK_ERROR = 3
|
|
74
|
+
$EXIT_UNSUPPORTED_OS = 4
|
|
75
|
+
$EXIT_NOT_ADMIN = 5
|
|
76
|
+
$EXIT_REBOOT_REQUIRED = 6
|
|
77
|
+
|
|
69
78
|
function Test-IsAdmin {
|
|
70
79
|
return ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
|
71
80
|
[Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
@@ -194,7 +203,7 @@ function Prompt-Reboot {
|
|
|
194
203
|
Write-Host " $INSTALL_PS1_COMMAND" -ForegroundColor Yellow
|
|
195
204
|
Write-Host ""
|
|
196
205
|
Read-Host "Press Enter to exit"
|
|
197
|
-
exit
|
|
206
|
+
exit $EXIT_REBOOT_REQUIRED
|
|
198
207
|
}
|
|
199
208
|
|
|
200
209
|
# Download Ubuntu rootfs and verify checksum. Returns local tar path.
|
|
@@ -221,7 +230,7 @@ function Get-UbuntuRootfs {
|
|
|
221
230
|
Write-Fail "Not enough disk space on $drive."
|
|
222
231
|
Write-Fail " Available : $([math]::Round($freeBytes / 1GB, 1)) GB"
|
|
223
232
|
Write-Fail " Required : ~4 GB"
|
|
224
|
-
exit
|
|
233
|
+
exit $EXIT_DISK_ERROR
|
|
225
234
|
}
|
|
226
235
|
|
|
227
236
|
# Check if a valid cached tarball exists (skip download if checksum passes)
|
|
@@ -242,14 +251,14 @@ function Get-UbuntuRootfs {
|
|
|
242
251
|
Write-Step "Downloading Ubuntu rootfs (~350 MB)..."
|
|
243
252
|
if (-not (Invoke-Download -Url $wslUrl -OutFile $tarPath)) {
|
|
244
253
|
Write-Fail "Failed to download Ubuntu rootfs. Check your network and try again."
|
|
245
|
-
exit
|
|
254
|
+
exit $EXIT_NETWORK_ERROR
|
|
246
255
|
}
|
|
247
256
|
Write-Success "Download complete."
|
|
248
257
|
|
|
249
258
|
Write-Step "Verifying checksum..."
|
|
250
259
|
if (-not (Test-Sha256 -FilePath $tarPath -Sha256Url $sha256Url)) {
|
|
251
260
|
Write-Fail "The downloaded file is corrupted. Please try again."
|
|
252
|
-
exit
|
|
261
|
+
exit $EXIT_GENERIC_ERROR
|
|
253
262
|
}
|
|
254
263
|
}
|
|
255
264
|
|
|
@@ -278,7 +287,7 @@ function Install-UbuntuRootfs {
|
|
|
278
287
|
Write-Fail "wsl --import failed (exit $LASTEXITCODE)."
|
|
279
288
|
if ($wslOutput) { Write-Fail "$wslOutput" }
|
|
280
289
|
Write-Fail "Try removing $UBUNTU_WSL_DIR and running the script again."
|
|
281
|
-
exit
|
|
290
|
+
exit $EXIT_GENERIC_ERROR
|
|
282
291
|
}
|
|
283
292
|
Write-Success "Ubuntu (WSL$WslVersion) imported successfully."
|
|
284
293
|
}
|
|
@@ -289,7 +298,7 @@ function Test-WslNetwork {
|
|
|
289
298
|
if ($LASTEXITCODE -ne 0) {
|
|
290
299
|
Write-Fail "WSL cannot reach $CLACKY_CDN_PRIMARY_HOST (curl exit $LASTEXITCODE)."
|
|
291
300
|
Write-Fail "Please fix the network inside WSL and re-run this installer."
|
|
292
|
-
exit
|
|
301
|
+
exit $EXIT_NETWORK_ERROR
|
|
293
302
|
}
|
|
294
303
|
Write-Success "WSL network OK."
|
|
295
304
|
}
|
|
@@ -304,7 +313,7 @@ function Run-InstallInWsl {
|
|
|
304
313
|
$localScript = Join-Path $scriptDir "install.sh"
|
|
305
314
|
if (-not (Test-Path $localScript)) {
|
|
306
315
|
Write-Fail "Local mode: install.sh not found at $localScript"
|
|
307
|
-
exit
|
|
316
|
+
exit $EXIT_GENERIC_ERROR
|
|
308
317
|
}
|
|
309
318
|
$wslPath = ($localScript -replace '\', '/') -replace '^([A-Za-z]):', { '/mnt/' + $args[0].Groups[1].Value.ToLower() }
|
|
310
319
|
Write-Info "Local mode: using $wslPath"
|
|
@@ -315,13 +324,13 @@ function Run-InstallInWsl {
|
|
|
315
324
|
}
|
|
316
325
|
|
|
317
326
|
if ($LASTEXITCODE -eq 2) {
|
|
318
|
-
exit
|
|
327
|
+
exit $EXIT_NETWORK_ERROR
|
|
319
328
|
}
|
|
320
329
|
if ($LASTEXITCODE -ne 0) {
|
|
321
330
|
Write-Fail "Installation failed inside WSL (exit $LASTEXITCODE)."
|
|
322
331
|
Write-Fail "You can retry manually:"
|
|
323
332
|
Write-Host " wsl -d Ubuntu -u root -- bash -c `"curl -fsSL $INSTALL_SCRIPT_URL | bash`"" -ForegroundColor Yellow
|
|
324
|
-
exit
|
|
333
|
+
exit $EXIT_GENERIC_ERROR
|
|
325
334
|
}
|
|
326
335
|
}
|
|
327
336
|
|
|
@@ -418,7 +427,8 @@ function Test-VirtualisationSupported {
|
|
|
418
427
|
New-Item -ItemType Directory -Force -Path $probeDir | Out-Null
|
|
419
428
|
|
|
420
429
|
Write-Info "[probe] Running: wsl --import $probeName $probeDir $TarPath --version 2"
|
|
421
|
-
wsl.exe --import $probeName $probeDir $TarPath --version 2
|
|
430
|
+
$probeOutput = wsl.exe --import $probeName $probeDir $TarPath --version 2 2>&1
|
|
431
|
+
if ($probeOutput) { Write-Info "WSL2 probe: $probeOutput" }
|
|
422
432
|
$importExit = $LASTEXITCODE
|
|
423
433
|
Write-Info "[probe] wsl --import exit code: $importExit"
|
|
424
434
|
$ok = ($importExit -eq 0)
|
|
@@ -442,6 +452,8 @@ function Test-VirtualisationSupported {
|
|
|
442
452
|
|
|
443
453
|
# Download and install the WSL2 kernel MSI from our CDN.
|
|
444
454
|
function Install-WslKernel {
|
|
455
|
+
param([switch]$Repair)
|
|
456
|
+
|
|
445
457
|
$cpuArch = Get-CpuArch
|
|
446
458
|
|
|
447
459
|
# Select the correct MSI for this CPU architecture.
|
|
@@ -456,7 +468,18 @@ function Install-WslKernel {
|
|
|
456
468
|
Write-Step "Downloading WSL kernel update ($cpuArch)..."
|
|
457
469
|
if (-not (Invoke-Download -Url $url -OutFile $msiPath)) {
|
|
458
470
|
Write-Fail "Failed to download WSL kernel update. Check your network and try again."
|
|
459
|
-
exit
|
|
471
|
+
exit $EXIT_NETWORK_ERROR
|
|
472
|
+
}
|
|
473
|
+
if ($Repair) {
|
|
474
|
+
# /fa = force repair all files; handles corrupt/partial installs where /i silently no-ops.
|
|
475
|
+
$proc = Start-Process msiexec -Wait -PassThru -ArgumentList "/fa", $msiPath, "/quiet", "/norestart"
|
|
476
|
+
Remove-Item -Force -ErrorAction SilentlyContinue $msiPath
|
|
477
|
+
if ($proc.ExitCode -ne 0) {
|
|
478
|
+
Write-Warn "WSL kernel repair failed (exit $($proc.ExitCode))."
|
|
479
|
+
return $false
|
|
480
|
+
}
|
|
481
|
+
Write-Success "WSL kernel repaired."
|
|
482
|
+
return $true
|
|
460
483
|
}
|
|
461
484
|
Write-Info "Installing WSL kernel..."
|
|
462
485
|
Start-Process msiexec -Wait -ArgumentList "/i", $msiPath, "/quiet", "/norestart"
|
|
@@ -488,7 +511,7 @@ if (-not (Test-IsAdmin)) {
|
|
|
488
511
|
Write-Host ""
|
|
489
512
|
Write-Host " Right-click PowerShell -> 'Run as administrator', then:" -ForegroundColor Yellow
|
|
490
513
|
Write-Host " $INSTALL_PS1_COMMAND" -ForegroundColor Yellow
|
|
491
|
-
exit
|
|
514
|
+
exit $EXIT_NOT_ADMIN
|
|
492
515
|
}
|
|
493
516
|
|
|
494
517
|
# Check minimum Windows version: WSL1 requires Build 16215 (Win10 1709).
|
|
@@ -497,7 +520,7 @@ if ($osBuild -lt 16215) {
|
|
|
497
520
|
Write-Fail "Unsupported Windows version (Build $osBuild)."
|
|
498
521
|
Write-Fail "WSL requires Windows 10 Build 16215 (version 1709) or later."
|
|
499
522
|
Write-Fail "Please update Windows and try again."
|
|
500
|
-
exit
|
|
523
|
+
exit $EXIT_UNSUPPORTED_OS
|
|
501
524
|
}
|
|
502
525
|
Write-Info "Windows Build $osBuild — OK."
|
|
503
526
|
|
|
@@ -516,12 +539,15 @@ if ($installPhase -eq "" -and $wslCode -ne 0) {
|
|
|
516
539
|
# Always exits (prompts reboot)
|
|
517
540
|
}
|
|
518
541
|
|
|
519
|
-
# phase == wsl-pending + code 1: reboot happened but WSL still not ready.
|
|
542
|
+
# phase == wsl-pending + code 1: reboot happened but WSL still not ready; try repair.
|
|
520
543
|
if ($installPhase -eq "wsl-pending" -and $wslCode -eq 1) {
|
|
521
|
-
Write-Warn "WSL features were enabled but WSL is still not ready."
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
544
|
+
Write-Warn "WSL features were enabled but WSL is still not ready. Attempting repair..."
|
|
545
|
+
$repairOk = Install-WslKernel -Repair
|
|
546
|
+
if (-not $repairOk) {
|
|
547
|
+
Write-Warn "Please reboot your computer and run the installer again."
|
|
548
|
+
Write-Warn "If this keeps happening, please contact our support team."
|
|
549
|
+
exit $EXIT_GENERIC_ERROR
|
|
550
|
+
}
|
|
525
551
|
}
|
|
526
552
|
|
|
527
553
|
# wslCode != 1 (0, -1, -444, 50, etc.): WSL is functional, continue.
|
|
@@ -568,7 +594,7 @@ if (Test-UbuntuInstalled) {
|
|
|
568
594
|
}
|
|
569
595
|
Write-Fail "Failed to import Ubuntu into both WSL1 and WSL2."
|
|
570
596
|
Write-Fail "Please ensure Windows Subsystem for Linux is enabled and try again."
|
|
571
|
-
exit
|
|
597
|
+
exit $EXIT_GENERIC_ERROR
|
|
572
598
|
}
|
|
573
599
|
}
|
|
574
600
|
}
|
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.16
|
|
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-10 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
|